<?php

/* vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4: */

/**
 * This file contains the following classes:
 *
 * - Image
 * - Image_Exception
 * - Image_UnsupportedTypeException
 * - Image_InvalidFilterException
 *
 * PHP version 5
 *
 * This file is part of Majic - A rapid-development web application framework
 * for PHP 5.
 *
 * Majic is free software: you can redistribute it and/or modify it under
 * the terms of the GNU Lesser General Public License as published by the Free
 * Software Foundation, either version 3 of the License, or (at your option)
 * any later version.
 *
 * Majic is distributed in the hope that it will be useful, but WITHOUT ANY
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
 * details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with Majic. If not, see <http://www.gnu.org/licenses/>.
 *
 * @package     Image
 * @author      Michael J. I. Jackson <mjijackson@gmail.com>
 * @copyright   2007-2008 Michael J. I. Jackson
 * @license     http://www.gnu.org/licenses/lgpl-3.0.txt GNU LGPL 3.0
 * @version     SVN: $Id: Image.php 41 2008-08-30 16:11:11Z mjijackson $
 */

/**
 * A wrapper class for the GD image manipulation library. Requires the GD PHP
 * extension.
 *
 * @package     Image
 * @author      Michael J. I. Jackson <mjijackson@gmail.com>
 */
class Image
{

    // operation mode constants
    const MODE_SAVE     = 1;
    const MODE_STREAM   = 2;
    const MODE_BOTH     = 3;

    /**
     * The default set of options to use for the class.
     *
     * @var     array
     * @access  private
     * @static
     */
    private static $default_options = array(
        'width'             => null,        // resize
        'height'            => null,
        'maintain_aspect'   => true,
        'crop_l'            => 0,           // crop
        'crop_r'            => 0,
        'crop_b'            => 0,
        'crop_t'            => 0,
        'flip_x'            => false,       // mirror
        'flip_y'            => false,
        'rotate'            => null,        // rotate
        'filter'            => null,        // filter
        'filter_args'       => array(),
        'quality'           => 75,          // quality
        'gamma_correct'     => null,        // gamma
        'output_path'       => false,       // output
        'output_format'     => null
    );

    /**
     * A map of IMAGETYPE_* constants to IMG_* constants.
     *
     * @var     array
     * @access  private
     * @static
     */
    private static $img_types = array(
        IMAGETYPE_GIF   => IMG_GIF,
        IMAGETYPE_JPEG  => IMG_JPEG,
        IMAGETYPE_PNG   => IMG_PNG,
        IMAGETYPE_WBMP  => IMG_WBMP
    );

    /**
     * Applies various alterations to the specified image using the specified
     * options. The options array keys can be the following:
     *
     * - width              The width for the new image. Defaults to the
     *                      original's width
     * - height             The height for the new image. Defaults to the
     *                      original's height
     * - maintain_aspect    True to maintain the original image's aspect ratio.
     *                      Defaults to true
     * - crop_l             The amount to crop from the left of the image.
     *                      Defaults to 0
     * - crop_r             The amount to crop from the right of the image.
     *                      Defaults to 0
     * - crop_b             The amount to crop from the bottom of the image.
     *                      Defaults to 0
     * - crop_t             The amount to crop from the top of the image.
     *                      Defaults to 0
     * - flip_x             True to flip the image horizontally. Defaults to
     *                      false
     * - flip_y             True to flip the image vertically. Defaults to false
     * - rotate             The angle to rotate the image. Defaults to 0
     * - filter             A filter to apply to the image (one of the
     *                      IMG_FILTER_* constants). Defaults to none
     * - filter_args        An array of arguments (or a single argument if only
     *                      one is needed) to pass to the image filter function
     * - quality            The resulting quality of the image (0-100,
     *                      applicable only when using jpg). Defaults to 75
     * - gamma_correct      An array of input and output gamma values
     * - output_path        The new path for the generated image (used only in
     *                      save mode). Defaults to the path of the original
     * - output_format      The format to use for the output image (one of the
     *                      IMG_* constants). Defaults first to the format given
     *                      in the output_path extension (if given), then to
     *                      the format of the original
     *
     * The mode parameter specifies what the function will do with the image
     * once it has been altered. It must be one of the Image::MODE_*
     * constants. There are three modes of operation:
     *
     * - Image::MODE_SAVE = Save the image to the path specified in the options array
     * - Image::MODE_STREAM = Output the image to the browser
     * - Image::MODE_BOTH = Save the image to the specified path and then output it to the browser
     *
     * Defaults to Image::MODE_SAVE. In stream mode, the proper headers will be
     * sent to the browser, followed by the image output stream. The script will
     * then immediately exit.
     *
     * The following example crops 100 pixels from the bottom of the image,
     * mirrors it horizontally, and outputs it to the browser:
     *
     * <code>
     * Image::alter('/path/to/my.jpg', array(
     *      'crop_b' => 100
     *      'flip_y' => true
     * ), Image::MODE_STREAM);
     * </code>
     *
     * The following example creates a thumbnail from the given image that is
     * no larger than 150x150 pixels, while maintaining the original's aspect
     * ratio, and outputs it in PNG format:
     *
     * <code>
     * Image::alter('/path/to/img.jpg' array(
     *     'height'        => 150,
     *     'width'         => 150,
     *     'output_path'   => '/path/to/img-thumb.png'
     * ), Image::MODE_SAVE);
     * </code>
     *
     * @param   string  $img_path           The path to the image to alter
     * @param   array   $opts               (optional) The options to use
     * @param   int     $mode               (optional) One of the
     *                                      Image::MODE_* constants,
     *                                      defaults to Image::MODE_SAVE
     * @throws  Image_Exception                     If the given image file
     *                                              cannot be found or the
     *                                              output path is not writable
     * @throws  Image_InvalidFilterException        If an invalid filter is
     *                                              given
     * @access  public
     * @static
     * @todo    multiple filters?
     */
    public static function alter($img_path, $opts = array(),
        $mode = self::MODE_SAVE)
    {
        // merge options
        $options = array_merge(self::$default_options, $opts);

        // make sure the source is valid
        $img_path = @realpath($img_path);
        if ($img_path === false) {
            throw new Image_Exception("Invalid image path: $img_path");
        }
        if ($mode & self::MODE_SAVE) {
            $out_path = $options['output_path'] ? $options['output_path'] : $img_path;
        }

        $src_img = self::create($img_path);
        $props = self::getProperties($img_path);
        $src_w = $props['width'];
        $src_h = $props['height'];

        // output format
        $img_type = $options['output_format'];
        if (is_null($img_type)) { // no output format explicitly given
            // guess from new path
            if ($img_path != $out_path && preg_match('/\.(\w+)$/', $out_path, $match)) {
                // new path given, guess from extension
                switch ($match[1]) {
                    case 'gif':
                        $img_type = IMG_GIF;
                    break;
                    case 'jpg':
                    case 'jpeg':
                        $img_type = IMG_JPEG;
                    break;
                    case 'png':
                        $img_type = IMG_PNG;
                    break;
                    case 'bmp':
                        $img_type = IMG_WBMP;
                    break;
                }
            }
            if (is_null($img_type)){
                // default to original's same format
                $img_type = $props['img_type'];
            }
        }
        if (!in_array($img_type, self::$img_types)) {
            throw new Image_Exception("Unsupported image output type: $img_type");
        }

        // crop
        $src_x = 0;
        if (is_numeric($options['crop_l'])) {
            $src_x += $options['crop_l'];
            $src_w -= $options['crop_l'];
        }
        if (is_numeric($options['crop_r'])) {
            $src_w -= $options['crop_r'];
        }
        $src_y = 0;
        if (is_numeric($options['crop_t'])) {
            $src_y += $options['crop_t'];
            $src_h -= $options['crop_t'];
        }
        if (is_numeric($options['crop_b'])) {
            $src_h -= $options['crop_b'];
        }

        // resize
        $new_w = is_numeric($options['width']) ? $options['width'] : $src_w;
        $new_h = is_numeric($options['height']) ? $options['height'] : $src_h;
        if ($options['maintain_aspect'] == true) {
            if (is_null($options['width'])) {
                // new height given
                $new_w = floor($src_w * $new_h / $src_h);
            } else if (is_null($options['height'])) {
                // new width given
                $new_h = floor($src_h * $new_w / $src_w);
            } else {
                // both new height and width given, calculate the constraining
                // side and then the other accordingly
                $delta_h = ($src_h - $new_h) / $src_h;
                $delta_w = ($src_w - $new_w) / $src_w;
                if ($delta_h > $delta_w) {
                    $new_w = floor($src_w * $new_h / $src_h);
                }
                if ($delta_w > $delta_h) {
                    $new_h = floor($src_h * $new_w / $src_w);
                }
            }
        }

        $new_img = imagecreatetruecolor($new_w, $new_h);
        imagecopyresampled($new_img, $src_img, 0, 0, $src_x, $src_y, $new_w, $new_h, $src_w, $src_h);
        imagedestroy($src_img);

        // mirror x
        $flip_x = is_bool($options['flip_x']) ? $options['flip_x'] : false;
        if ($flip_x) {
            for ($y = 0; $y < $new_h; $y++) {
                $l  = 0;
                $r = $new_w - 1;
                while ($l < $r) {
                    $cl = imagecolorat($new_img, $l, $y);
                    $cr = imagecolorat($new_img, $r, $y);
                    imagesetpixel($new_img, $l++, $y, $cr);
                    imagesetpixel($new_img, $r--, $y, $cl);
                }
            }
        }

        // mirror y
        $flip_y = is_bool($options['flip_y']) ? $options['flip_y'] : false;
        if ($flip_y) {
            for ($x = 0; $x < $new_w; $x++) {
                $t = 0;
                $b = $new_h - 1;
                while ($t < $b) {
                    $ct = imagecolorat($new_img, $x, $t);
                    $cb = imagecolorat($new_img, $x, $b);
                    imagesetpixel($new_img, $x, $t++, $cb);
                    imagesetpixel($new_img, $x, $b--, $ct);
                }
            }
        }

        // gamma correct
        $gm = $options['gamma_correct'];
        if (is_array($gm) && count($gm) == 2 && is_numeric($gm[0]) && is_numeric($gm[1])) {
            imagegammacorrect($new_img, $gm[0], $gm[1]);
        }

        // filter
        $filter = $options['filter'];
        if ($filter != null) {
            if (!in_array($filter, self::getValidFilters())) {
                throw new Image_InvalidFilterException($filter);
            }

            $filter_args = empty($options['filter_args']) ? array() : $options['filter_args'];
            if (!is_array($filter_args)) {
                $filter_args = array($filter_args);
            }
            array_unshift($filter_args, $filter);
            array_unshift($filter_args, $new_img);

            call_user_func_array('imagefilter', $filter_args);
        }

        // rotate
        $angle = $options['rotate'];
        if (is_numeric($angle)) {
            $tmp_img = imagerotate($new_img, $angle, imagecolortransparent($new_img));
            imagedestroy($new_img);
            $new_img = $tmp_img;
        }

        // save the image?
        if ($mode & self::MODE_SAVE) {
            switch ($img_type) {
                case IMG_GIF:
                    imagegif($new_img, $out_path);
                break;
                case IMG_JPEG:
                    imagejpeg($new_img, $out_path, $options['quality']);
                break;
                case IMG_PNG:
                    imagepng($new_img, $out_path);
                break;
                case IMG_WBMP:
                    imagewbmp($new_img, $out_path);
                break;
            }
        }

        // output the image?
        if ($mode & self::MODE_STREAM) {
            $image_type = array_search($img_type, self::$img_types);
            $mime_type = image_type_to_mime_type($image_type);
            $last_mod = gmdate('D, d M Y H:i:s', time());

            header('Content-Transfer-Encoding: binary');
            header("Last-Modified: $last_mod GMT");
            header("Content-Type: $mime_type");

            switch ($img_type) {
                case IMG_GIF:
                    imagegif($new_img);
                break;
                case IMG_JPEG:
                    imagejpeg($new_img, null, $options['quality']);
                break;
                case IMG_PNG:
                    imagepng($new_img);
                break;
                case IMG_WBMP:
                    imagewbmp($new_img);
                break;
            }
        }

        imagedestroy($new_img);

        if ($mode & self::MODE_STREAM) {
            exit(0);
        }
    }

    /**
     * Gets some common properties of the given image.
     *
     * @param   string  $img_path       The path to the image
     * @return  array                   An array containing the image's
     *                                  properties
     * @access  public
     * @static
     */
    public static function getProperties($img_path)
    {
        $props = getimagesize($img_path);
        return array(
            'width'         => $props[0],
            'height'        => $props[1],
            'img_type'      => $props[2],
            'size_str'      => $props[3],
            'mime_type'     => $props['mime']
        );
    }

    /**
     * Creates a resource handle for the image at the specified path.
     *
     * @param   string      $img_path       The path to the image
     * @return  resource                    A resource handle for the image
     * @throws  Image_UnsupportedTypeException      If the image type is not
     *                                              supported
     * @access  public
     * @static
     */
    public static function create($img_path)
    {
        $props = self::getProperties($img_path);
        $img_type = $props['img_type'];

        // is the image type supported?
        $img_const = @self::$img_types[$img_type];
        if (!$img_const || imagetypes() & $img_const === 0) {
                throw new Image_UnsupportedTypeException($props['mime_type']);
        }

        switch ($img_type) {
            case IMAGETYPE_GIF:
                $image = @imagecreatefromgif($img_path);
                break;
            case IMAGETYPE_JPEG:
                $image = @imagecreatefromjpeg($img_path);
                break;
            case IMAGETYPE_PNG:
                $image = @imagecreatefrompng($img_path);
                break;
            case IMAGETYPE_WBMP:
                $image = @imagecreatefromwbmp($img_path);
                break;
        }

        return $image;
    }

    /**
     * Returns an array of all valid image filters that may be used.
     *
     * @return  array           An array of all valid image filter constants
     * @access  private
     * @static
     */
    private static function getValidFilters()
    {
        return array(
            IMG_FILTER_NEGATE,
            IMG_FILTER_GRAYSCALE,
            IMG_FILTER_BRIGHTNESS,
            IMG_FILTER_CONTRAST,
            IMG_FILTER_COLORIZE,
            IMG_FILTER_EDGEDETECT,
            IMG_FILTER_EMBOSS,
            IMG_FILTER_GAUSSIAN_BLUR,
            IMG_FILTER_SELECTIVE_BLUR,
            IMG_FILTER_MEAN_REMOVAL,
            IMG_FILTER_SMOOTH
        );
    }

}

/**
 * Image exception. The base exception class for all Image exceptions.
 *
 * @package     Image
 * @author      Michael J. I. Jackson <mjijackson@gmail.com>
 */
class Image_Exception extends Majic_Exception {}

/**
 * Image type not supported exception. Thrown when the image type that the user
 * is trying to use is not supported.
 *
 * @package     Image
 * @author      Michael J. I. Jackson <mjijackson@gmail.com>
 */
class Image_UnsupportedTypeException extends Image_Exception
{

    /**
     * Constructor.
     *
     * @param   string  $mime       The image mime type
     * @access  public
     */
    public function __construct($mime)
    {
        parent::__construct("Image type $mime is not supported");
    }

}

/**
 * Image invalid filter exception. Thrown when the user tries to use an
 * unrecognized filter on an image.
 *
 * @package     Image
 * @author      Michael J. I. Jackson <mjijackson@gmail.com>
 */
class Image_InvalidFilterException extends Image_Exception
{

    /**
     * Constructor.
     *
     * @param   int     $filter     The filter that was used
     * @access  public
     */
    public function __construct($filter)
    {
        parent::__construct("Invalid image filter ($filter)");
    }

}

?>
